Skip to content

Add rate limit to Port client #262

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open

Add rate limit to Port client #262

wants to merge 18 commits into from

Conversation

erikzaadi
Copy link
Member

Description

What - Add rate limit capabilities to the base port http client
Why - Prevent failing on multiple 429
How - Add resty middle ware that detects rate limits and acts accordingly

Type of change

Please leave one option from the following and delete the rest:

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Non-breaking change (fix of existing functionality that will not change current behavior)
  • Documentation (added/updated documentation)

@erikzaadi erikzaadi force-pushed the PORT-15207-rate-limit branch from 99b4284 to 01d6aa3 Compare June 23, 2025 18:56
Copy link

github-actions bot commented Jun 23, 2025

TestsPassed ✅SkippedFailedTime ⏱
JUnit Test Report134 ran134 ✅0 ⚠️0 ❌14m 31s 598ms
TestRetriesTime ⏱
JUnit Test Report
com/port-labs/terraform-provider-port-labs/v2/port/entity.TestAccPortEntityWithManyRelation110s 230ms
com/port-labs/terraform-provider-port-labs/v2/port/team.TestAccPortTeamEmptyDescription19s 180ms
com/port-labs/terraform-provider-port-labs/v2/port/team.TestAccPortTeamUpdate113s 290ms
com/port-labs/terraform-provider-port-labs/v2/port/team.TestAccPortTeamImport16s 870ms
com/port-labs/terraform-provider-port-labs/v2/port/team.TestAccPortTeam14s 160ms

Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/15832472715/artifacts/3386161105

Code Coverage Total Percentage: 41.5%

Copy link

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 41.5

Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/15832678263/artifacts/3386278375

Code Coverage Total Percentage: 63.1%

Copy link

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 63.1

Copy link
Contributor

@Eyal-Shalev Eyal-Shalev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are 2 things I don't like about this PR:

  1. You are implementing a rate limit yourself which seems an unnecessary complication. I'm sure there are great libraries for something this important.
  2. The rate limit is set globally. Shouldn't it only affect retries on the same request?

@erikzaadi erikzaadi force-pushed the PORT-15207-rate-limit branch from 2bc681c to 889d9e2 Compare June 24, 2025 08:59
Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/15846001956/artifacts/3390640592

Code Coverage Total Percentage: 66.1%

Copy link

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 66.1

Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/15846038502/artifacts/3390655240

Code Coverage Total Percentage: 78.1%

Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/15965961435/artifacts/3429784483

Code Coverage Total Percentage: 50.6%

Copy link

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 50.6

@erikzaadi erikzaadi requested a review from Eyal-Shalev June 30, 2025 14:57
@erikzaadi erikzaadi force-pushed the PORT-15207-rate-limit branch from 875daef to 903a17d Compare July 6, 2025 05:52
Copy link

github-actions bot commented Jul 6, 2025

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16095891109/artifacts/3471447766

Code Coverage Total Percentage: 63.4%

Copy link

github-actions bot commented Jul 6, 2025

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 63.4

Copy link

github-actions bot commented Jul 6, 2025

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16096719592/artifacts/3471644206

Code Coverage Total Percentage: 66.2%

Copy link

github-actions bot commented Jul 6, 2025

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 66.2

Copy link

github-actions bot commented Jul 6, 2025

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16096719592/artifacts/3471797920

Code Coverage Total Percentage: 66.2%

Copy link

github-actions bot commented Jul 6, 2025

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 66.2

Copy link

github-actions bot commented Jul 6, 2025

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16097790940/artifacts/3471874623

Code Coverage Total Percentage: 63.2%

Copy link

github-actions bot commented Jul 6, 2025

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 63.2

@erikzaadi erikzaadi force-pushed the PORT-15207-rate-limit branch from 2ec5266 to bd1e329 Compare July 7, 2025 06:44
Copy link

github-actions bot commented Jul 7, 2025

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16109839014/artifacts/3474717624

Code Coverage Total Percentage: 66.6%

Copy link

github-actions bot commented Jul 7, 2025

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 66.6

Comment on lines 148 to 156
// WithRateLimitThreshold sets the threshold for when to start throttling
// threshold should be between 0.0 and 1.0 (e.g., 0.1 means start throttling when 10% of requests remain)
func WithRateLimitThreshold(threshold float64) Option {
return func(pc *PortClient) {
if threshold >= 0.0 && threshold <= 1.0 {
pc.rateLimitManager.SetThreshold(threshold)
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too obscure. I think that you should return an error or panic if it is out of bounds.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option is to clamp the threshold between 0 and 1.

m.mu.Lock()
m.activeRequests--
if m.activeRequests < 0 {
m.activeRequests = 0 // Prevent negative count
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would log stuff like this as it shows we have a bug in our code

@erikzaadi erikzaadi force-pushed the PORT-15207-rate-limit branch from bd1e329 to 51cf48e Compare July 13, 2025 12:25
Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16249103961/artifacts/3521483633

Code Coverage Total Percentage: 63.9%

Copy link

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 66.8

@erikzaadi erikzaadi force-pushed the PORT-15207-rate-limit branch from bb27428 to 3d0ce6e Compare July 14, 2025 13:14
Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16266967554/artifacts/3526735039

Code Coverage Total Percentage: 76.2%

Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16266979758/artifacts/3526813956

Code Coverage Total Percentage: 72.5%

Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16267878717/artifacts/3526902115

Code Coverage Total Percentage: 76.4%

Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16268452431/artifacts/3527252617

Code Coverage Total Percentage: 67.1%

Copy link

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 67.1

Copy link

Code Coverage Artifact 📈: https://github.com/port-labs/terraform-provider-port-labs/actions/runs/16268263599/artifacts/3527308241

Code Coverage Total Percentage: 63.5%

Copy link

🚨 The new code coverage percentage is lower than the current one. Current coverage: 71.4
While the new one is: 63.5

Copy link
Contributor

@Eyal-Shalev Eyal-Shalev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +314 to +317
// Use recover to catch any potential panics from semaphore operations
if r := recover(); r != nil {
m.logger.Debug("Recovered from panic in ResponseMiddleware defer", "panic", r)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this to work you need to wrap it inside a new defer func() {...}

Suggested change
// Use recover to catch any potential panics from semaphore operations
if r := recover(); r != nil {
m.logger.Debug("Recovered from panic in ResponseMiddleware defer", "panic", r)
}
defer func() {
// Use recover to catch any potential panics from semaphore operations
if r := recover(); r != nil {
m.logger.Debug("Recovered from panic in ResponseMiddleware defer", "panic", r)
}
}

Comment on lines +225 to +235
// Try to acquire semaphore immediately (non-blocking) as load indicator
ctx, cancel := context.WithTimeout(context.Background(), 0)
defer cancel()

semaphoreAcquired := false
if err := m.requestSemaphore.Acquire(ctx, 1); err != nil {
m.logger.Warn("High concurrent load detected - proceeding anyway")
} else {
m.logger.Debug("Normal load - acquired semaphore slot")
semaphoreAcquired = true
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought you wanted to have a timeout on the Acquire, if you don't you can just use TryAcquire instead.

Suggested change
// Try to acquire semaphore immediately (non-blocking) as load indicator
ctx, cancel := context.WithTimeout(context.Background(), 0)
defer cancel()
semaphoreAcquired := false
if err := m.requestSemaphore.Acquire(ctx, 1); err != nil {
m.logger.Warn("High concurrent load detected - proceeding anyway")
} else {
m.logger.Debug("Normal load - acquired semaphore slot")
semaphoreAcquired = true
}
semaphoreAcquired := m.requestSemaphore.TryAcquire(1)
if semaphoreAcquired {
m.logger.Debug("Normal load - acquired semaphore slot")
} else {
m.logger.Warn("High concurrent load detected - proceeding anyway")
}

Comment on lines +334 to +339
activeRequests := m.activeRequests.Add(-1)
if activeRequests < 0 {
// Reset to 0 if somehow went negative
m.activeRequests.Store(0)
activeRequests = 0
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't atomic as well.

I see you choose to use a mutex, so just use it everywhere you do a check-and-update for m.activeRequests and change m.activeRequests to be a normal int.

Comment on lines +18 to +19
// contextKey is a custom type for context keys to avoid collisions
type contextKey string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this comment is needed.

Suggested change
// contextKey is a custom type for context keys to avoid collisions
type contextKey string
type contextKey string

assert.Nil(t, rateLimitInfo)
}

// Test-specific option functions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove comment. This is a test file so of course it is test specific.

Suggested change
// Test-specific option functions

Comment on lines +69 to +87
func TestClientRateLimitDisabled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("x-ratelimit-limit", "1000")
w.Header().Set("x-ratelimit-remaining", "1")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok": true}`))
}))
defer server.Close()

client, err := New(server.URL, WithRateLimitDisabled())
require.NoError(t, err)

resp, err := client.Client.R().Get("/test")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode())

rateLimitInfo := client.RateLimitManager.GetInfo()
assert.Nil(t, rateLimitInfo)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already tested in ratelimit package. No need to double test things.

Comment on lines +89 to +120
func TestClientRateLimitThrottling(t *testing.T) {
skipIfDisabled(t)

requestCount := 0

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Header().Set("x-ratelimit-limit", "100")
w.Header().Set("x-ratelimit-remaining", "5")
w.Header().Set("x-ratelimit-reset", "2")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok": true}`))
}))
defer server.Close()

client, err := New(server.URL, WithRateLimitThreshold(0.1))
require.NoError(t, err)

start := time.Now()
resp, err := client.Client.R().Get("/test1")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode())

resp, err = client.Client.R().Get("/test2")
elapsed := time.Since(start)

require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode())
assert.Equal(t, 2, requestCount)

assert.Greater(t, elapsed, 10*time.Millisecond, "Request should have been throttled")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already tested in ratelimit package. No need to double test things.

Comment on lines +310 to +317
t.Run("with nil options uses defaults", func(t *testing.T) {
manager := NewManager(nil)
assert.NotNil(t, manager)
assert.NotNil(t, manager.logger)
assert.Equal(t, 0.02, manager.threshold)
assert.Equal(t, 50*time.Millisecond, manager.minRequestInterval)
assert.Equal(t, 30*time.Second, manager.cleanupInterval)
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is needed. But if you do test the defaults, please use const-s for the hard coded values. Or at least call the function that creates a default options struct.

Comment on lines +453 to +465
func TestDefaultManagerOptions(t *testing.T) {
defaults := DefaultManagerOptions()

assert.NotNil(t, defaults.Logger)
assert.NotNil(t, defaults.Threshold)
assert.Equal(t, 0.02, *defaults.Threshold)
assert.NotNil(t, defaults.SemaphoreWeight)
assert.Equal(t, int64(50), *defaults.SemaphoreWeight)
assert.NotNil(t, defaults.MinRequestInterval)
assert.Equal(t, 50*time.Millisecond, *defaults.MinRequestInterval)
assert.NotNil(t, defaults.CleanupInterval)
assert.Equal(t, 30*time.Second, *defaults.CleanupInterval)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't we already test the defaults in the above test?

Comment on lines +527 to +531
semaphoreWeight := int64(1)
manager := NewManager(&ManagerOptions{
SemaphoreWeight: &semaphoreWeight,
Logger: logger,
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can do stuff like this.

Suggested change
semaphoreWeight := int64(1)
manager := NewManager(&ManagerOptions{
SemaphoreWeight: &semaphoreWeight,
Logger: logger,
})
manager := NewManager(&ManagerOptions{
SemaphoreWeight: toPtr(1),
Logger: logger,
})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants